StockWriteForm.tsx 5.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189
  1. 'use client';
  2. // D2 종목 글쓰기 폼 — 제목/본문 + 선택적 예측(방향·기간·목표가).
  3. // POST /api/boards/stock/posts (프록시 경유, JWT). 성공 시 라우터 새로고침으로 목록 갱신.
  4. import '@/app/styles/community.scss';
  5. import { useState } from 'react';
  6. import { useRouter } from 'next/navigation';
  7. import { fetchApi } from '@/lib/utils/client';
  8. import {
  9. CreateStockPostRequest,
  10. CreateStockPostResponse,
  11. PredictionDirection,
  12. PredictionHorizon,
  13. PredictionDirectionValue,
  14. PredictionHorizonValue
  15. } from '@/types/community';
  16. interface Props {
  17. stockCode: string;
  18. stockName?: string|null;
  19. onDone?: () => void;
  20. }
  21. export default function StockWriteForm({ stockCode, stockName, onDone }: Props)
  22. {
  23. const router = useRouter();
  24. const [subject, setSubject] = useState('');
  25. const [content, setContent] = useState('');
  26. const [withPrediction, setWithPrediction] = useState(false);
  27. const [direction, setDirection] = useState<PredictionDirectionValue>(PredictionDirection.Up);
  28. const [horizon, setHorizon] = useState<PredictionHorizonValue>(PredictionHorizon.Short);
  29. const [targetPrice, setTargetPrice] = useState('');
  30. const [submitting, setSubmitting] = useState(false);
  31. const [error, setError] = useState<string|null>(null);
  32. async function handleSubmit(e: React.FormEvent) {
  33. e.preventDefault();
  34. setError(null);
  35. if (!subject.trim()) {
  36. setError('제목을 입력해주세요.');
  37. return;
  38. }
  39. const body: CreateStockPostRequest = {
  40. stockCode,
  41. subject: subject.trim(),
  42. content
  43. };
  44. if (withPrediction) {
  45. const parsedTarget = targetPrice.trim() ? Number(targetPrice.replace(/,/g, '')) : null;
  46. if (parsedTarget !== null && (Number.isNaN(parsedTarget) || parsedTarget <= 0)) {
  47. setError('목표가는 0보다 큰 숫자여야 합니다.');
  48. return;
  49. }
  50. body.prediction = {
  51. direction,
  52. horizonDays: horizon,
  53. targetPrice: parsedTarget
  54. };
  55. }
  56. setSubmitting(true);
  57. try {
  58. const res = await fetchApi<CreateStockPostResponse>('/api/boards/stock/posts', {
  59. method: 'POST',
  60. body,
  61. silent: true
  62. });
  63. if (!res.success) {
  64. setError(res.message || (res.errors && res.errors[0]?.description) || '글 등록에 실패했습니다.');
  65. setSubmitting(false);
  66. return;
  67. }
  68. setSubject('');
  69. setContent('');
  70. setWithPrediction(false);
  71. setTargetPrice('');
  72. onDone?.();
  73. router.refresh();
  74. } catch (err) {
  75. setError(err instanceof Error ? err.message : '글 등록에 실패했습니다.');
  76. } finally {
  77. setSubmitting(false);
  78. }
  79. }
  80. return (
  81. <form className='stock-write' onSubmit={handleSubmit}>
  82. <div className='stock-write__title'>{stockName ? `${stockName}(${stockCode})` : stockCode} 토론 글쓰기</div>
  83. <div className='stock-write__field'>
  84. <label htmlFor='stock-write-subject'>제목</label>
  85. <input
  86. id='stock-write-subject'
  87. type='text'
  88. value={subject}
  89. onChange={e => setSubject(e.target.value)}
  90. maxLength={200}
  91. placeholder='제목을 입력하세요'
  92. />
  93. </div>
  94. <div className='stock-write__field'>
  95. <label htmlFor='stock-write-content'>내용</label>
  96. <textarea
  97. id='stock-write-content'
  98. value={content}
  99. onChange={e => setContent(e.target.value)}
  100. placeholder='내용을 입력하세요. 다른 종목은 $005930 형식으로 태그할 수 있습니다.'
  101. ></textarea>
  102. </div>
  103. <div className='stock-write__prediction'>
  104. <div className='stock-write__prediction-head'>
  105. <label>
  106. <input
  107. type='checkbox'
  108. checked={withPrediction}
  109. onChange={e => setWithPrediction(e.target.checked)}
  110. />
  111. {' '}예측 등록 (방향·기간·목표가) — 채점되어 적중률에 반영됩니다
  112. </label>
  113. </div>
  114. {withPrediction && (
  115. <div className='stock-write__prediction-grid'>
  116. <div className='stock-write__field'>
  117. <label htmlFor='stock-write-direction'>방향</label>
  118. <select
  119. id='stock-write-direction'
  120. value={direction}
  121. onChange={e => setDirection(Number(e.target.value) as PredictionDirectionValue)}
  122. >
  123. <option value={PredictionDirection.Up}>▲ 상승</option>
  124. <option value={PredictionDirection.Down}>▼ 하락</option>
  125. </select>
  126. </div>
  127. <div className='stock-write__field'>
  128. <label htmlFor='stock-write-horizon'>기간</label>
  129. <select
  130. id='stock-write-horizon'
  131. value={horizon}
  132. onChange={e => setHorizon(Number(e.target.value) as PredictionHorizonValue)}
  133. >
  134. <option value={PredictionHorizon.Short}>5영업일</option>
  135. <option value={PredictionHorizon.Long}>20영업일</option>
  136. </select>
  137. </div>
  138. <div className='stock-write__field'>
  139. <label htmlFor='stock-write-target'>목표가 (선택)</label>
  140. <input
  141. id='stock-write-target'
  142. type='number'
  143. min='0'
  144. value={targetPrice}
  145. onChange={e => setTargetPrice(e.target.value)}
  146. placeholder='예: 75000'
  147. />
  148. </div>
  149. </div>
  150. )}
  151. <p className='stock-write__hint'>
  152. 예측 기준가는 작성 시점 최신 종가(T+1)로 스냅샷되며, 삭제해도 채점 기록은 유지됩니다.
  153. </p>
  154. </div>
  155. {error && <p className='stock-write__error' role='alert'>{error}</p>}
  156. <div className='stock-write__actions'>
  157. <button type='button' className='stock-write__btn' onClick={() => onDone?.()} disabled={submitting}>
  158. 취소
  159. </button>
  160. <button type='submit' className='stock-write__btn stock-write__btn--primary' disabled={submitting}>
  161. {submitting ? '등록 중...' : '등록'}
  162. </button>
  163. </div>
  164. </form>
  165. );
  166. }